Skip to content

arena: what is freed during a purge pass is purged by the next pass - #39

Merged
Jarred-Sumner merged 3 commits into
oven-sh:bun-dev3-v2from
robobun:robobun/39824388/purge-behind-pass
Sep 13, 2026
Merged

Jarred-Sumner merged 3 commits into
oven-sh:bun-dev3-v2from
robobun:robobun/39824388/purge-behind-pass

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #38: review the second and third commit.

Problem

  • Memory freed while the scavenger is in a purge pass is never purged by it. It stays until a thread parks or something forces a collect. A repro prints 266 of 320 MiB purged after 65 s.
  • _mi_arenas_try_purge (src/arena.c) keeps its old subproc->purge_expire during a pass, so a free in that time arms the arena only. It then skips its update, and mi_scavenger_run sets the value to 0.
  • No later free re-arms the scavenger, because the arena is armed.

Fix

  • A pass resets subproc->purge_expire first, as mi_arena_try_purge does for an arena. A free behind the pass then arms it and wakes the scavenger. At its end the pass puts back the earliest pending arena expire.
  • The scavenger no longer clears the value. It retries in 1 ms if another thread is in a pass. After 30 s idle it looks at the arenas.
  • The fork child handler makes a pass due if fork() cut one off. Process exit on Windows releases the purge guard (third commit).
  • Verified: new test/test-purge-behind-pass.c (5 of 5 fail before, 30 of 30 pass after). Full ctest passes.

Background

  • A purge returns freed arena slices to the OS (madvise), purge_delay (100 ms) after the free.
  • arena->purge_expire is when an arena is due. A free sets it only from 0: it arms the arena.
  • subproc->purge_expire is the earliest of these. The scavenger thread sleeps until then. A free sets it only from 0, after it armed the arena.
Notes

The 30 s timeout did not help. With purge_expire at 0 the scavenger picks a 30 s wait and waits again. It never looked at the arenas on that path, so the comment there ("any per-arena expiry that did not propagate to subproc is still eventually purged") was not true. The repro still has 64 MiB unpurged after two of those timeouts.

Where it came from. Commit 3fdbfb3 ("scavenger: 30s safety-net timeout, exact expire-now wait") made _mi_arenas_try_purge install the earliest pending arena expire with a CAS at the end of a pass, and made the scavenger CAS the value to 0 after that. Upstream left the stale value in place and polled.

Two defects, one protocol. (1) With visit_all, max_purge_count = max_arena, so the break for it fires on the last arena. With one arena each pass that purges something ends with all_visited = false. The final update and the read of that arena's expire are both skipped. (2) With that fixed there is still a window. A free into arena 0 after the pass read its expire, and before the final CAS, arms arena 0 alone. The window spans the passes over all later arenas. The reset at the start closes both.

Ordering. The pass resets subproc->purge_expire (CAS loop), then reads each arena expire with a CAS (0 -> 0) after its walk. A free does CAS(arena: 0 -> E), then CAS(subproc: 0 -> E). The two CAS operations on the arena expire are ordered. If the free is first, the pass reads E and puts it back. If the pass is first, the free reads its write, so it also sees the reset and its second CAS succeeds. A plain load in the pass allows both to miss (store buffering), as the comment on scavenger_wake in mi_scavenger_run describes for the park path.

The scavenger. The old CAS to 0 was an ABA hazard by itself: _mi_arenas_purge_now stores the current time, which can be the value the scavenger read. A first version here backed off exponentially while purge_expire stayed due and unchanged. A stress run showed the wait pinned at its 16 s cap, because parks wake the loop at kHz rates and each iteration doubled it. One round in 60 then stalled with a due purge_expire. The final version uses the return value from #38: dropped means retry in 1 ms, not dropped and not reset means purging got switched off at run time.

mi_subproc_schedule_purge wakes the scavenger on each change. A thread other than the scavenger can run the pass (mi_collect, a thread exit, a poll without visit_all). The scavenger can read 0 during that pass and sleep for 30 s. The wake covers that. On the scavenger's own thread it is one futex call with no waiter.

Fork. With the reset at the start, a fork() in the middle of a pass leaves the child with purge_expire at 0 and nobody to put back what is pending. Before this change the stale due value stayed, so the child's next poll ran a pass. _mi_arenas_forked_child now reports whether it released a held guard, and _mi_process_fork_child makes a pass due for each sub-process in that case. The second part of the test covers it: without these lines it fails 2 of 3 runs.

Second commit: process exit on Windows. mi_process_done runs in the process detach callback on Windows, after the system terminated every other thread. A thread that was in a pass then holds the purge guard for good, and the forced collect in mi_process_done_once waits for it without end (the wait is from #38). The new test returns from main while the scavenger is still in its last pass, so the Windows Debug jobs of the first CI run here hung at exit (5 of 6 local runs on Windows x64, 0 of 8 with the second commit, full Debug ctest 31 of 31). mi_process_done_once now releases the guard on Windows. _mi_arenas_forked_child becomes _mi_arenas_purge_guard_reset, as it serves both cases.

In bun. JSC registers its structure heap as a second, exclusive arena (mi_manage_os_memory_ex in StructureAlignedMemoryAllocator.cpp). That arena rarely has anything to purge, so a pass over both ends with all_visited = true, reads the re-armed expire of the main arena after its walk, and installs it. That hides the first defect in bun's default configuration: the same script that loses 128 MB with one arena gets its second pass on time with two. It shows when each arena purges in the same pass, and with one arena (Malloc=1 makes JSC allocate through malloc, which is mimalloc on Linux): then bun keeps purged: 274 MB of 384 MB until the loop goes idle.

Stress (local harness, not in the PR). 8 to 16 threads that stay alive allocate and free 1 to 8 MiB blocks, with and without mi_collect(true), mi_collect(false) and short parks. They pause without a park or an exit. The main thread reads the arena and sub-process expires and the purge bitmaps. On the base (8565de2): 15 of 15 rounds end with armed arenas=1 subproc expire=0 and about 530 MiB of free, committed ranges with their purge bit set. With this change: 750 rounds in three configurations, 0 stuck, 0 ranges left, quiet after 103 ms at most. Also with MIMALLOC_ARENA_RESERVE=65536 (9 arenas).

More purge activity. Ranges that used to stay resident by accident are now purged purge_delay after they were freed. That is what the option promises.

Not changed, seen on the way (upstream windows, all narrow). mi_arena_schedule_purge sets the purge bits after it arms the arena, and _mi_arenas_free marks the slices free after that. A forced pass can reset an arena expire that a free set a moment before, with the bits of that free landing behind the walk. A walk that meets purge bits before the slices are marked free clears them without a purge. Each heals on the next free into the arena.

Local runs (Linux x64, clang 17 and gcc 14). Full ctest: Release 31 of 31, Debug 32 of 32. The purge, park and fork tests also pass with ASAN, UBSAN, MI_GUARDED, MI_SECURE and MI_USE_CXX. The new test passes 30 of 30 runs, 10 of 10 pinned to one CPU, and with MIMALLOC_PURGE_DELAY=10 and 1000. TSAN does not run in my container (the runtime cannot disable ASLR there), so that is for CI here.

The bun side is oven-sh/bun#42543 for #38. A bun PR for this one follows.

… dropped

Only one thread runs an arena purge pass at a time. `_mi_arenas_try_purge`
takes a try-guard and returns when another thread holds it, forced or not.

With the scavenger thread there is often a pass in progress: it starts one
`purge_delay` after a free, and one more each time a parked thread's sweep ends
in `_mi_arenas_purge_now`. A pass over a few hundred MiB holds the guard for
tens of milliseconds. A `mi_collect(true)` in that window returned with
nothing purged. The scavenger's pass is no substitute: it is never forced, so
it leaves every arena whose delay has not passed, and it does not go back for
ranges that were freed behind it.

`_mi_arenas_try_purge` now reports whether it was dropped, and
`_mi_arenas_collect` retries a forced purge until it had its turn (spin, then
yield, as `_mi_park_leave` does). The holder takes no lock and waits for no
thread under the guard, so the wait is bounded by its madvise calls. Passes
that are not forced are still dropped as before.

The guard moves to file scope so that the fork child handler can release it:
the thread that held it across fork() is not in the child, and a forced purge
there would wait forever.

test-forced-purge frees 256 MiB a round, waits for the scavenger to enter an
arena (`arena_purges`), forces a collect, and checks that `purged` covers all
of it. Before: 3 of 4 rounds fail (1 to 4 MiB of 256 purged). After: 4 of 4.
A free arms its arena (`arena->purge_expire`) if the arena is not armed yet, and
only then arms the subprocess (`subproc->purge_expire`, 0 -> set), which is what
the scavenger sleeps on. A pass resets the arena expire when it goes into the
arena, so the first free into it during the pass arms it again. The pass kept
its old subprocess expire until its end though, so that free found it set and
left it alone. After the pass:

- `_mi_arenas_try_purge` skipped its `purge_expire` update. The break for
  `max_purge_count` also fired on the last arena, so with one arena (the usual
  case) each pass that purged something ended with `all_visited = false`.
- `mi_scavenger_run` then set `subproc->purge_expire` to 0.

The arena was armed and the subprocess was not. Each later free into the arena
saw an armed arena and did not look further. Nothing came back for those ranges
until a thread parked (`_mi_arenas_purge_now`) or something forced a collect.
The 30 s timeout of the scavenger did not help: with nothing scheduled it only
waits again. A process whose threads stay busy kept the memory.

With `all_visited` fixed there was still a window: a free into an arena between
the read of its expire and the update of the subprocess expire, which spans the
passes over all later arenas.

Now the pass resets the subprocess expire first, as `mi_arena_try_purge` does
for an arena, so a free behind the pass arms the subprocess and wakes the
scavenger. At its end the pass puts back the earliest arena expire that is still
pending (`mi_subproc_schedule_purge`: earliest wins, wakes the scavenger). It
reads each arena expire with a CAS (0 -> 0), so it is ordered with the CAS of a
free on it: either the pass sees the arena armed, or the free sees the reset.

The scavenger no longer clears the subprocess expire: a value that was set to
the same time again would be lost that way. A pass resets it. If its own pass
was dropped because another thread is in one, it comes back in 1 ms. After 30 s
with nothing scheduled and no wake it looks at the arenas all the same, which
makes the safety net that its comment promised real.

A fork in the middle of a pass leaves the child with the subprocess expire
reset and no thread that ends the pass. The fork child handler makes a pass due
in that case.

test-purge-behind-pass: frees 256 MiB, waits for the scavenger to go into the
arena (`arena_purges`), frees 64 MiB more, and checks that `purged` covers all
of it within 10 s, without a park or a collect. Before: 266 of 320 MiB, 5 of 5
runs. After: 30 of 30 runs pass, about 100 ms after the second batch. A second
part forks in the middle of a pass and checks that the child purges an arena
that the pass had yet to come to.
On Windows `mi_process_done` runs in the process detach callback, after the
system terminated every other thread. A thread that was in the middle of a
purge pass then (the scavenger, as a rule) holds the purge guard for good. The
forced collect in `mi_process_done_once` waits for that guard without end,
since a forced purge waits for the pass in progress.

test-purge-behind-pass returns from main while the scavenger is still in its
last pass, which is how this shows: the Debug build (`MI_DEBUG_FULL`) hangs at
exit in 5 of 6 runs on Windows x64. With this: 0 of 8, and the full ctest
passes.

`_mi_arenas_forked_child` becomes `_mi_arenas_purge_guard_reset`, as it serves
both cases now.
@Jarred-Sumner
Jarred-Sumner merged commit 961ac76 into oven-sh:bun-dev3-v2 Sep 13, 2026
15 checks passed
Jarred-Sumner pushed a commit to oven-sh/bun that referenced this pull request Sep 13, 2026
Bumps mimalloc to 707d90be (oven-sh/mimalloc#39, which is stacked on
oven-sh/mimalloc#38).

The allocator's purge thread (the scavenger) hands freed arena memory back to
the OS 100 ms after the free. What a thread freed while the scavenger was in
the middle of a pass could be left out for good: the arena was marked as
scheduled, the scavenger was not, and no later free into that arena scheduled
it again. The memory stayed resident until the event loop went idle or
something forced a collection. It took a pass in which each arena had
something to purge. JSC's structure heap is a mimalloc arena of its own that
rarely has, which hides most of it in bun. With one arena (Malloc=1) each
such free was lost.

The pass now resets the schedule before it looks at the arenas, so a free
behind it schedules the next pass, and puts back what is still pending at its
end.

The second commit of oven-sh/mimalloc#39 (707d90be) releases the purge guard
in mi_process_done on Windows, where the process detach callback runs after
the system terminated the purge thread and the forced collect at exit would
wait for that guard without end. bun builds mimalloc with
MI_NO_PROCESS_DETACH and never runs mi_process_done, so that part is for the
fork's own tests.
Jarred-Sumner added a commit that referenced this pull request Sep 13, 2026
…rd at exit on Windows)

Brings in #38 and #39 from the fork branch (8565de2, 1b894af, 707d90b) on top of the merged upstream arena code.

src/arena.c, src/scavenger.c, src/subproc.c and include/mimalloc/internal.h merge without overlap: upstream did not
touch `_mi_arenas_try_purge`, `_mi_arenas_collect` or `mi_arena_schedule_purge` between the two bases. The one upstream
change next to them is in `mi_arena_try_purge`, which now leaves an arena whose expire is 0 alone for a forced pass as
well. That is compatible: a free arms the arena before it sets its purge bits, and the pass reads the arena expire with
the 0 -> 0 CAS after the walk, as before.

Conflicts:
- CMakeLists.txt: the test list keeps `profile` in place of `prof`/`prof-adversarial` and the `MI_TRACK STREQUAL "ASAN"`
  condition from the upstream side, and gains `forced-purge` and `purge-behind-pass`.
- src/init.c (`mi_process_done_once`): the Windows release of the purge guard goes before the heap snapshot; the call
  to `_mi_prof_on_exit` stays removed with the fork's profiler.

test-forced-purge and test-purge-behind-pass set `minimal_purge_size` to one slice. They compare the `purged` counter
with the bytes they freed, and with upstream's MI_ALLOW_THP=FULL (the Linux default of a standalone build) a pass
purges aligned 2 MiB units only: each freed run keeps its edges (the runs break at the 256 MiB aligned page meta), so a
release build read 252 of 256 MiB and 316 of 320 MiB.
Jarred-Sumner added a commit to oven-sh/bun that referenced this pull request Sep 13, 2026
…freed during a pass is purged by the next (#42571)

Both fork PRs are merged: oven-sh/mimalloc#38 and oven-sh/mimalloc#39.
The pin `707d90be` is on `bun-dev3-v2` (they were merged with merge
commits, so the sha did not change). This PR now carries both bumps and
both tests; it replaces #42543.

### Two fixes in the fork

**1. A forced purge waits for the pass in progress
(oven-sh/mimalloc#38).**
- `Bun.gc(true)` ends with `mi_collect(true)` since #42428. mimalloc
dropped that purge when another thread held its purge guard, and the
purge thread holds it for 36 to 47 ms while it purges what was already
freed. `Bun.gc(true)` then returned with all of it still resident.
- `test/js/bun/spawn/spawn-pipe-leak.test.ts` fails on main because of
it: `Peak RSS: warmup 46 MB -> main 312 MB (573%)`, 7 of 10 runs pass.
With this pin: 25 of 25.
- Only a forced collect waits. bun's one caller is
`garbage_collect_from_js`. It takes 29 to 47 ms in place of 1 to 2 ms
when it collides with a pass over 384 MB, and the memory is back with
the OS when it returns.
- Test: `test/js/bun/gc/gc-controller-cadence.test.ts`, "Bun.gc(true)
returns what is free to the OS while the purge thread is at work". Fails
3 of 3 on main's release build (38 to 42 MB released, expected more than
300).

**2. What is freed during a purge pass is purged by the next pass
(oven-sh/mimalloc#39).** The rest of this description.

### Problem
- Memory freed while mimalloc's purge thread (the scavenger) is in a
pass can stay resident until the event loop goes idle or something
forces a collection. With one arena (`Malloc=1`) a busy script keeps 110
of 384 MB: `{"released":262.6,"purged":274.4}`.
- In the fork, `_mi_arenas_try_purge` (`src/arena.c`) keeps its old
`subproc->purge_expire` during a pass, so a free in that time arms the
arena only. The scavenger then clears the value, and no later free sets
it again.
- It reaches bun's default configuration. JSC's structure heap is a
second arena that rarely purges, which hides the case of one busy
thread. With several busy threads and no ticking event loop it shows
each time: 8 workers that churn `ArrayBuffer`s and then spin hold 578 MB
RSS on main's release build and fall to 91 MB with this pin.

### Fix
- Bump `MIMALLOC_COMMIT` to `707d90be` (oven-sh/mimalloc#39, which
contains oven-sh/mimalloc#38). The bump also picks up `0fecc729`, a
32-bit shift fix in `src/prof.c` that was already on `bun-dev3-v2`.
- In the fork a pass now resets `subproc->purge_expire` first and puts
back what is pending at its end, so a free behind the pass schedules the
next one.
- Verified: the new test in `test/js/bun/jsc/heapStats-mimalloc.test.ts`
fails 3 of 3 on main's release build, passes 20 of 20 with this pin.
`bun bd test` skips it (ASAN).

### Background
- A purge returns freed mimalloc arena memory to the OS (`madvise`), 100
ms after the free.
- The scavenger is the fork's thread that runs those purges. It sleeps
until `subproc->purge_expire`.
- The first free into an arena after a pass arms it
(`arena->purge_expire`). Only that free looks at
`subproc->purge_expire`.

<details><summary>Notes</summary>

**The two commits of oven-sh/mimalloc#39.**
- `1b894afe` arena: what is freed during a purge pass is purged by the
next pass (the fix)
- `707d90be` init: release the purge guard at process exit on Windows

**The free path (`mi_arena_schedule_purge`) is unchanged.** Only the
pass and the scavenger loop change.

**The defect in full.** A free arms its arena (`arena->purge_expire`)
and, only if the arena was not armed yet, arms the subprocess
(`subproc->purge_expire`, 0 -> set), which is what the scavenger sleeps
on. A pass resets the arena expire when it goes into the arena, but it
kept its old subprocess expire until its end. The first free into the
arena behind the pass armed the arena again, found the subprocess expire
still set, and left it alone. Then `_mi_arenas_try_purge` skipped its
update (the break for `max_purge_count` also fires on the last arena, so
a pass in which each arena purged ended with `all_visited = false`), and
`mi_scavenger_run` set `subproc->purge_expire` to 0. The arena was armed
and the subprocess was not. Each later free into that arena saw an armed
arena and stopped there. The 30 s timeout of the scavenger did not help:
with nothing scheduled it only waits again.

**Reach in bun.** JSC registers its structure heap as an exclusive
mimalloc arena (`mi_manage_os_memory_ex` in
`StructureAlignedMemoryAllocator.cpp`). A pass over two arenas in which
only the main one purges ends with `all_visited = true`, reads the
re-armed expire of the main arena after its walk, and installs it. So
the same script that loses 128 MB with one arena gets its second pass on
time with two (traced on main's release build: first pass at 107 to 123
ms, second at 207 ms). It shows when both arenas purge in the same pass,
when a process has more arenas (more than 1 GiB of mimalloc memory) and
each purges, and with `Malloc=1`. The event loop's
`mi_on_thread_idle_start` on each `epoll_wait` heals it when the sweep
runs to its end. Under load the owner wakes first and the sweep stops
before `_mi_arenas_purge_now`.

The state is absorbing: once an arena is armed and the subprocess is
not, nothing but a park or a forced collect ends it. So a low chance per
pass is enough for a process whose threads stay busy. Measured with
stock two arena bun (release, Linux x64): 8 workers allocate and free 1
to 8 MB `ArrayBuffer`s for 3 s (`transfer(0)` frees at once), free
everything, and spin, while the main thread sits in `Atomics.wait`.
After a quiet 2.5 s RSS is 578 MB on main and 91 MB with this pin
(`arena_count` 2 in both). The same happens with only the one line fix
for `all_visited` in the fork, which is why the pass resets the
subprocess expire first.

**The test.** A child frees 256 MB with
`ArrayBuffer.prototype.transfer(0)` (no collection involved), spins
until RSS fell by 64 MB (the purge thread is in its pass), frees 128 MB
more, and spins without an `await` until RSS fell by 336 MB, for at most
2 s. It reports that drop and the growth of
`heapStats().mimalloc.purged`. Both have to be at least 336 MB. The drop
in RSS is taken before the last `heapStats()` call, because that call
polls the allocator (`mi_collect(false)`), and a poll runs a pass that
is due by itself. `Malloc=1` makes JSC allocate through `malloc`, which
is mimalloc on Linux, so there is one arena. Linux only (the wait reads
RSS) and not ASAN (malloc is not mimalloc there).

| build | result |
| --- | --- |
| release build of main (`6a92015fc`, pin `6a64e1ba`) | fails 3 of 3:
`{"released":262.6,"purged":274.4}`, expected >= 336 |
| `bun run build:release` with this pin | passes 20 of 20, about 450 ms
|
| `bun bd test` with this pin (debug, ASAN) | new test skipped, the
other 4 tests in the file pass |

The change is under `scripts/`, so a checkout of `src/` from main does
not undo it. The fail-before check needs a release build of main.

**About `707d90be`.** `1b894afe` alone failed the 5 Windows jobs of the
fork's CI. Its new test returns from `main` while the scavenger is in
its last pass. On Windows `mi_process_done` runs in the process detach
callback, after the system terminated every other thread, so the purge
guard stays held, and the forced collect at exit waits for it without
end (that wait is what oven-sh/mimalloc#38 adds). `707d90be` releases
the guard there. bun builds mimalloc with `MI_NO_PROCESS_DETACH` and
`MI_SKIP_COLLECT_ON_EXIT`, so bun does not reach that code on any
platform. Fork CI at `707d90be`: 15 of 15 jobs pass (Windows x64 and
arm64, mingw, macOS, Linux x64 and arm64 with ASAN, UBSAN, TSAN and
guarded builds, FreeBSD).

**In the fork.** New `test/test-purge-behind-pass.c`: 5 of 5 runs fail
before (`266 of 320 MiB`), 30 of 30 pass after. A stress harness with 8
to 16 busy threads: 15 of 15 rounds end stuck before, with about 530 MiB
of free committed ranges unpurged. After: 750 rounds, 0 stuck. Details
are in oven-sh/mimalloc#39.

**Effect on purge activity.** Ranges that stayed resident by accident
are now purged 100 ms after the free, which is what `purge_delay`
promises.

**Suites run with a release build of this branch (Linux x64).**
`test/js/bun/jsc/heapStats-mimalloc.test.ts`,
`test/js/bun/gc/gc-controller-cadence.test.ts` (11 pass),
`test/js/bun/spawn/spawn-pipe-leak.test.ts` (3 pass),
`test/js/bun/spawn/spawn-noread-leak.test.ts`,
`test/js/bun/jsc/bun-jsc.test.ts` (38 pass). Nothing here was run on
macOS or Windows.

</details>




### Now pins the merged upstream sync

`MIMALLOC_COMMIT` is `ab13501334a8da2b64ab2e5f4f552c3a39b96350`: the
merge of oven-sh/mimalloc#37 on `bun-dev3-v2`. It contains `707d90be`
(both fixes above), so everything in this description still holds.

**What it adds beyond oven-sh/mimalloc#38 and oven-sh/mimalloc#39**
- microsoft/mimalloc `dev3` through v3.5.2 (301 commits): always-on
statistics (`MI_STATS=1`), sampled profiler hooks (`mi_profiler_t`,
`MI_PROFILE=1`), codegen work in free/zalloc/memzero, double-free
detection through the padding canary.
- The fork's own heap profiler is gone (`src/prof.c`, `mi_prof_*`,
`MIMALLOC_PROF_*`); upstream's hooks replace it. bun referenced none of
it.
- `fork()`: no arena purge pass runs across it any more. That was the
macOS Debug SIGBUS of a forked child in the fork's CI, and a whole class
of torn purge state in the child.
- `mi_heap_destroy` of a heap that still has a block on a page's
thread-free list no longer reports corrupted meta-data.
- Two slips in upstream's `free.c` are fixed: `mi_usable_size` always
took its slow path, and an overflow could be reported as a double free.
- The malloc/free fast paths update upstream's packed used count with
one instruction (`decw` / `addq` on memory).

**One new define: `MI_FREE_USE_PAGEMAP=1`**
- Upstream's new default keeps page meta data at 256 MiB boundaries so
that `mi_free` needs no page map. Every arena then has to start on such
a boundary.
- JSC gives mimalloc its structure heap as an arena
(`mi_manage_os_memory_ex(start + 16 KiB, ...)`, under a
`RELEASE_ASSERT`), and halves that reservation when address space is
short. With the new default `mi_manage_os_memory_ex` refuses it once the
reservation is 256 MiB or less.
- Measured with a release build of the new pin without the define:
`ulimit -v 3000000; bun -e 1` aborts on startup, and so does
`BUN_JSC_structureHeapSizeInKB=262144`. The old pin runs both. With the
default 4 GiB reservation it works but loses the first 256 MiB of
structure address space.
- With the define, `mi_free` looks the page up through the page map as
it does today, and the structure heap works down to 64 MiB as before.
The fork's CI runs this configuration (debug and release) on Linux x64
and arm64.
- The aligned layout is worth another 1.1% of instructions on the
malloc/free benchmark below. Getting it needs WebKit to hand mimalloc an
aligned region (`mi_arena_min_alignment()`), which is a separate change.

**Not changed: `MI_PROFILE` stays at its default of 1.** Nothing in bun
attaches a profiler yet, and `MI_PROFILE=0` would save about 1.4% of
instructions on the same benchmark. The hooks are what a
`Bun.pprof.heap` API would build on, so they stay compiled in.

**Bindings.** Every `mi_*` function that `src/mimalloc_sys/mimalloc.rs`
and the C++ side declare is exported with an unchanged declaration.
`mi_heap_area_t` has the same layout. `mi_option_t` 0..46 did not move
(47 is `collect_merges_stats` now, `snapshot_on_exit` 48); bun sets
option 0 only. The `heapStats().mimalloc` keys the tests read (`purged`,
`page_bins`, `pages`, `committed`) are emitted as before.

**Allocator micro benchmark** (25 M `mi_malloc` + 25 M `mi_free` of
16..1039 bytes, bun's compile flags, user-mode instructions):

| | instructions |
| --- | --- |
| old pin (`707d90be`) | 2.2463 G |
| this pin, `MI_FREE_USE_PAGEMAP=1` (what this PR builds) | **2.2403 G**
(-0.3%) |
| this pin, upstream's aligned layout | 2.2153 G (-1.4%) |

**bun, release build, old pin against this commit** (same tree, only the
pin and the define differ; 5 interleaved runs pinned to 8 cores;
user-mode instructions and peak RSS, medians):

| | old pin | this commit |
| --- | --- | --- |
| `bun -e 1` | 9.17 M, 25.9 MB | 9.16 M (-0.06%), 25.7 MB |
| `Bun.serve` hello, per request (20000 sequential `fetch`) | 51748,
48.2 MB | 51787 (+0.08%), 48.5 MB |
| `JSON.stringify` + `JSON.parse` of a 200-user object, 3000 times |
5102.3 M, 42.6 MB | 5102.9 M (+0.01%), 42.6 MB |
| string concat + `Buffer.from` + `split`, 300 x 5000 | 1939.8 M, 58.9
MB | 1904.3 M (-1.8%, the spread between runs is 2%), 57.8 MB |
| idle after start (`Bun.sleep`), RSS from `smaps_rollup` | 32.4 MB
(anon 3.85) | 32.5 MB (anon 3.96) |

Nothing is slower by more than 0.1%.

**Tests with a release build of this commit (Linux x64)**
- `test/js/bun/spawn/spawn-pipe-leak.test.ts`: 25 of 25 runs pass.
`test/js/bun/jsc/heapStats-mimalloc.test.ts` and
`test/js/bun/gc/gc-controller-cadence.test.ts`: 10 of 10 each.
- 137 test files, 4280 tests pass: the two files of this PR,
`spawn-noread-leak`, `require-cache`, `heap-snapshot`, `bun-jsc`,
`socket-retention`, `serve.test.ts`, all of
`test/js/node/child_process`, `node-net`, `bunshell`, `fetch.test.ts`,
`bun-install.test.ts`, `bundler_edgecase`, all of `test/js/web/workers`
and `test/js/node/worker_threads`, `test/js/bun/resolve`,
`test/js/bun/jsc`, the `--compile` and bundler files, `test/bake/dev`.
- 8 tests fail, and the same 8 fail with a release build of the old pin:
they need a non-root user (a port below 1024 that must be refused,
`EPERM` after dropping privileges, unreadable files and directories).
- Small structure heaps: `BUN_JSC_structureHeapSizeInKB` = 262144,
131072, 65536 and `ulimit -v` = 3000000, 2000000, 1500000 all start.
- Debug (ASAN) build: `heapStats-mimalloc`, `gc-controller-cadence`,
`test/js/web/workers/worker.test.ts` pass; `child_process.test.ts` has
the one root-only failure.
- `src/static.c` compiles with the new define and bun's flags for macOS
arm64/x64 and Windows x64/arm64 (release and debug). Nothing here was
run on macOS or Windows.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/bun/jsc/heapStats-mimalloc.test.ts,
test/js/bun/gc/gc-controller-cadence.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…freed during a pass is purged by the next (oven-sh#42571)

Both fork PRs are merged: oven-sh/mimalloc#38 and oven-sh/mimalloc#39.
The pin `707d90be` is on `bun-dev3-v2` (they were merged with merge
commits, so the sha did not change). This PR now carries both bumps and
both tests; it replaces oven-sh#42543.

### Two fixes in the fork

**1. A forced purge waits for the pass in progress
(oven-sh/mimalloc#38).**
- `Bun.gc(true)` ends with `mi_collect(true)` since oven-sh#42428. mimalloc
dropped that purge when another thread held its purge guard, and the
purge thread holds it for 36 to 47 ms while it purges what was already
freed. `Bun.gc(true)` then returned with all of it still resident.
- `test/js/bun/spawn/spawn-pipe-leak.test.ts` fails on main because of
it: `Peak RSS: warmup 46 MB -> main 312 MB (573%)`, 7 of 10 runs pass.
With this pin: 25 of 25.
- Only a forced collect waits. bun's one caller is
`garbage_collect_from_js`. It takes 29 to 47 ms in place of 1 to 2 ms
when it collides with a pass over 384 MB, and the memory is back with
the OS when it returns.
- Test: `test/js/bun/gc/gc-controller-cadence.test.ts`, "Bun.gc(true)
returns what is free to the OS while the purge thread is at work". Fails
3 of 3 on main's release build (38 to 42 MB released, expected more than
300).

**2. What is freed during a purge pass is purged by the next pass
(oven-sh/mimalloc#39).** The rest of this description.

### Problem
- Memory freed while mimalloc's purge thread (the scavenger) is in a
pass can stay resident until the event loop goes idle or something
forces a collection. With one arena (`Malloc=1`) a busy script keeps 110
of 384 MB: `{"released":262.6,"purged":274.4}`.
- In the fork, `_mi_arenas_try_purge` (`src/arena.c`) keeps its old
`subproc->purge_expire` during a pass, so a free in that time arms the
arena only. The scavenger then clears the value, and no later free sets
it again.
- It reaches bun's default configuration. JSC's structure heap is a
second arena that rarely purges, which hides the case of one busy
thread. With several busy threads and no ticking event loop it shows
each time: 8 workers that churn `ArrayBuffer`s and then spin hold 578 MB
RSS on main's release build and fall to 91 MB with this pin.

### Fix
- Bump `MIMALLOC_COMMIT` to `707d90be` (oven-sh/mimalloc#39, which
contains oven-sh/mimalloc#38). The bump also picks up `0fecc729`, a
32-bit shift fix in `src/prof.c` that was already on `bun-dev3-v2`.
- In the fork a pass now resets `subproc->purge_expire` first and puts
back what is pending at its end, so a free behind the pass schedules the
next one.
- Verified: the new test in `test/js/bun/jsc/heapStats-mimalloc.test.ts`
fails 3 of 3 on main's release build, passes 20 of 20 with this pin.
`bun bd test` skips it (ASAN).

### Background
- A purge returns freed mimalloc arena memory to the OS (`madvise`), 100
ms after the free.
- The scavenger is the fork's thread that runs those purges. It sleeps
until `subproc->purge_expire`.
- The first free into an arena after a pass arms it
(`arena->purge_expire`). Only that free looks at
`subproc->purge_expire`.

<details><summary>Notes</summary>

**The two commits of oven-sh/mimalloc#39.**
- `1b894afe` arena: what is freed during a purge pass is purged by the
next pass (the fix)
- `707d90be` init: release the purge guard at process exit on Windows

**The free path (`mi_arena_schedule_purge`) is unchanged.** Only the
pass and the scavenger loop change.

**The defect in full.** A free arms its arena (`arena->purge_expire`)
and, only if the arena was not armed yet, arms the subprocess
(`subproc->purge_expire`, 0 -> set), which is what the scavenger sleeps
on. A pass resets the arena expire when it goes into the arena, but it
kept its old subprocess expire until its end. The first free into the
arena behind the pass armed the arena again, found the subprocess expire
still set, and left it alone. Then `_mi_arenas_try_purge` skipped its
update (the break for `max_purge_count` also fires on the last arena, so
a pass in which each arena purged ended with `all_visited = false`), and
`mi_scavenger_run` set `subproc->purge_expire` to 0. The arena was armed
and the subprocess was not. Each later free into that arena saw an armed
arena and stopped there. The 30 s timeout of the scavenger did not help:
with nothing scheduled it only waits again.

**Reach in bun.** JSC registers its structure heap as an exclusive
mimalloc arena (`mi_manage_os_memory_ex` in
`StructureAlignedMemoryAllocator.cpp`). A pass over two arenas in which
only the main one purges ends with `all_visited = true`, reads the
re-armed expire of the main arena after its walk, and installs it. So
the same script that loses 128 MB with one arena gets its second pass on
time with two (traced on main's release build: first pass at 107 to 123
ms, second at 207 ms). It shows when both arenas purge in the same pass,
when a process has more arenas (more than 1 GiB of mimalloc memory) and
each purges, and with `Malloc=1`. The event loop's
`mi_on_thread_idle_start` on each `epoll_wait` heals it when the sweep
runs to its end. Under load the owner wakes first and the sweep stops
before `_mi_arenas_purge_now`.

The state is absorbing: once an arena is armed and the subprocess is
not, nothing but a park or a forced collect ends it. So a low chance per
pass is enough for a process whose threads stay busy. Measured with
stock two arena bun (release, Linux x64): 8 workers allocate and free 1
to 8 MB `ArrayBuffer`s for 3 s (`transfer(0)` frees at once), free
everything, and spin, while the main thread sits in `Atomics.wait`.
After a quiet 2.5 s RSS is 578 MB on main and 91 MB with this pin
(`arena_count` 2 in both). The same happens with only the one line fix
for `all_visited` in the fork, which is why the pass resets the
subprocess expire first.

**The test.** A child frees 256 MB with
`ArrayBuffer.prototype.transfer(0)` (no collection involved), spins
until RSS fell by 64 MB (the purge thread is in its pass), frees 128 MB
more, and spins without an `await` until RSS fell by 336 MB, for at most
2 s. It reports that drop and the growth of
`heapStats().mimalloc.purged`. Both have to be at least 336 MB. The drop
in RSS is taken before the last `heapStats()` call, because that call
polls the allocator (`mi_collect(false)`), and a poll runs a pass that
is due by itself. `Malloc=1` makes JSC allocate through `malloc`, which
is mimalloc on Linux, so there is one arena. Linux only (the wait reads
RSS) and not ASAN (malloc is not mimalloc there).

| build | result |
| --- | --- |
| release build of main (`6a92015fc`, pin `6a64e1ba`) | fails 3 of 3:
`{"released":262.6,"purged":274.4}`, expected >= 336 |
| `bun run build:release` with this pin | passes 20 of 20, about 450 ms
|
| `bun bd test` with this pin (debug, ASAN) | new test skipped, the
other 4 tests in the file pass |

The change is under `scripts/`, so a checkout of `src/` from main does
not undo it. The fail-before check needs a release build of main.

**About `707d90be`.** `1b894afe` alone failed the 5 Windows jobs of the
fork's CI. Its new test returns from `main` while the scavenger is in
its last pass. On Windows `mi_process_done` runs in the process detach
callback, after the system terminated every other thread, so the purge
guard stays held, and the forced collect at exit waits for it without
end (that wait is what oven-sh/mimalloc#38 adds). `707d90be` releases
the guard there. bun builds mimalloc with `MI_NO_PROCESS_DETACH` and
`MI_SKIP_COLLECT_ON_EXIT`, so bun does not reach that code on any
platform. Fork CI at `707d90be`: 15 of 15 jobs pass (Windows x64 and
arm64, mingw, macOS, Linux x64 and arm64 with ASAN, UBSAN, TSAN and
guarded builds, FreeBSD).

**In the fork.** New `test/test-purge-behind-pass.c`: 5 of 5 runs fail
before (`266 of 320 MiB`), 30 of 30 pass after. A stress harness with 8
to 16 busy threads: 15 of 15 rounds end stuck before, with about 530 MiB
of free committed ranges unpurged. After: 750 rounds, 0 stuck. Details
are in oven-sh/mimalloc#39.

**Effect on purge activity.** Ranges that stayed resident by accident
are now purged 100 ms after the free, which is what `purge_delay`
promises.

**Suites run with a release build of this branch (Linux x64).**
`test/js/bun/jsc/heapStats-mimalloc.test.ts`,
`test/js/bun/gc/gc-controller-cadence.test.ts` (11 pass),
`test/js/bun/spawn/spawn-pipe-leak.test.ts` (3 pass),
`test/js/bun/spawn/spawn-noread-leak.test.ts`,
`test/js/bun/jsc/bun-jsc.test.ts` (38 pass). Nothing here was run on
macOS or Windows.

</details>




### Now pins the merged upstream sync

`MIMALLOC_COMMIT` is `ab13501334a8da2b64ab2e5f4f552c3a39b96350`: the
merge of oven-sh/mimalloc#37 on `bun-dev3-v2`. It contains `707d90be`
(both fixes above), so everything in this description still holds.

**What it adds beyond oven-sh/mimalloc#38 and oven-sh/mimalloc#39**
- microsoft/mimalloc `dev3` through v3.5.2 (301 commits): always-on
statistics (`MI_STATS=1`), sampled profiler hooks (`mi_profiler_t`,
`MI_PROFILE=1`), codegen work in free/zalloc/memzero, double-free
detection through the padding canary.
- The fork's own heap profiler is gone (`src/prof.c`, `mi_prof_*`,
`MIMALLOC_PROF_*`); upstream's hooks replace it. bun referenced none of
it.
- `fork()`: no arena purge pass runs across it any more. That was the
macOS Debug SIGBUS of a forked child in the fork's CI, and a whole class
of torn purge state in the child.
- `mi_heap_destroy` of a heap that still has a block on a page's
thread-free list no longer reports corrupted meta-data.
- Two slips in upstream's `free.c` are fixed: `mi_usable_size` always
took its slow path, and an overflow could be reported as a double free.
- The malloc/free fast paths update upstream's packed used count with
one instruction (`decw` / `addq` on memory).

**One new define: `MI_FREE_USE_PAGEMAP=1`**
- Upstream's new default keeps page meta data at 256 MiB boundaries so
that `mi_free` needs no page map. Every arena then has to start on such
a boundary.
- JSC gives mimalloc its structure heap as an arena
(`mi_manage_os_memory_ex(start + 16 KiB, ...)`, under a
`RELEASE_ASSERT`), and halves that reservation when address space is
short. With the new default `mi_manage_os_memory_ex` refuses it once the
reservation is 256 MiB or less.
- Measured with a release build of the new pin without the define:
`ulimit -v 3000000; bun -e 1` aborts on startup, and so does
`BUN_JSC_structureHeapSizeInKB=262144`. The old pin runs both. With the
default 4 GiB reservation it works but loses the first 256 MiB of
structure address space.
- With the define, `mi_free` looks the page up through the page map as
it does today, and the structure heap works down to 64 MiB as before.
The fork's CI runs this configuration (debug and release) on Linux x64
and arm64.
- The aligned layout is worth another 1.1% of instructions on the
malloc/free benchmark below. Getting it needs WebKit to hand mimalloc an
aligned region (`mi_arena_min_alignment()`), which is a separate change.

**Not changed: `MI_PROFILE` stays at its default of 1.** Nothing in bun
attaches a profiler yet, and `MI_PROFILE=0` would save about 1.4% of
instructions on the same benchmark. The hooks are what a
`Bun.pprof.heap` API would build on, so they stay compiled in.

**Bindings.** Every `mi_*` function that `src/mimalloc_sys/mimalloc.rs`
and the C++ side declare is exported with an unchanged declaration.
`mi_heap_area_t` has the same layout. `mi_option_t` 0..46 did not move
(47 is `collect_merges_stats` now, `snapshot_on_exit` 48); bun sets
option 0 only. The `heapStats().mimalloc` keys the tests read (`purged`,
`page_bins`, `pages`, `committed`) are emitted as before.

**Allocator micro benchmark** (25 M `mi_malloc` + 25 M `mi_free` of
16..1039 bytes, bun's compile flags, user-mode instructions):

| | instructions |
| --- | --- |
| old pin (`707d90be`) | 2.2463 G |
| this pin, `MI_FREE_USE_PAGEMAP=1` (what this PR builds) | **2.2403 G**
(-0.3%) |
| this pin, upstream's aligned layout | 2.2153 G (-1.4%) |

**bun, release build, old pin against this commit** (same tree, only the
pin and the define differ; 5 interleaved runs pinned to 8 cores;
user-mode instructions and peak RSS, medians):

| | old pin | this commit |
| --- | --- | --- |
| `bun -e 1` | 9.17 M, 25.9 MB | 9.16 M (-0.06%), 25.7 MB |
| `Bun.serve` hello, per request (20000 sequential `fetch`) | 51748,
48.2 MB | 51787 (+0.08%), 48.5 MB |
| `JSON.stringify` + `JSON.parse` of a 200-user object, 3000 times |
5102.3 M, 42.6 MB | 5102.9 M (+0.01%), 42.6 MB |
| string concat + `Buffer.from` + `split`, 300 x 5000 | 1939.8 M, 58.9
MB | 1904.3 M (-1.8%, the spread between runs is 2%), 57.8 MB |
| idle after start (`Bun.sleep`), RSS from `smaps_rollup` | 32.4 MB
(anon 3.85) | 32.5 MB (anon 3.96) |

Nothing is slower by more than 0.1%.

**Tests with a release build of this commit (Linux x64)**
- `test/js/bun/spawn/spawn-pipe-leak.test.ts`: 25 of 25 runs pass.
`test/js/bun/jsc/heapStats-mimalloc.test.ts` and
`test/js/bun/gc/gc-controller-cadence.test.ts`: 10 of 10 each.
- 137 test files, 4280 tests pass: the two files of this PR,
`spawn-noread-leak`, `require-cache`, `heap-snapshot`, `bun-jsc`,
`socket-retention`, `serve.test.ts`, all of
`test/js/node/child_process`, `node-net`, `bunshell`, `fetch.test.ts`,
`bun-install.test.ts`, `bundler_edgecase`, all of `test/js/web/workers`
and `test/js/node/worker_threads`, `test/js/bun/resolve`,
`test/js/bun/jsc`, the `--compile` and bundler files, `test/bake/dev`.
- 8 tests fail, and the same 8 fail with a release build of the old pin:
they need a non-root user (a port below 1024 that must be refused,
`EPERM` after dropping privileges, unreadable files and directories).
- Small structure heaps: `BUN_JSC_structureHeapSizeInKB` = 262144,
131072, 65536 and `ulimit -v` = 3000000, 2000000, 1500000 all start.
- Debug (ASAN) build: `heapStats-mimalloc`, `gc-controller-cadence`,
`test/js/web/workers/worker.test.ts` pass; `child_process.test.ts` has
the one root-only failure.
- `src/static.c` compiles with the new define and bun's flags for macOS
arm64/x64 and Windows x64/arm64 (release and debug). Nothing here was
run on macOS or Windows.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/bun/jsc/heapStats-mimalloc.test.ts,
test/js/bun/gc/gc-controller-cadence.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants